iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
AI Engineering

AI 的駕馭之道:一個 AI Code Reviewer 的養成、評測與邊界實錄系列 第 20

Day 20|容器的網路邊界:iptables 白名單,與少寫兩個引號留下的漏洞

  • 分享至 

  • xImage
  •  

Day 20:iptables 預設拒絕,只逐條放行必要流量

簡短回顧

昨天我們把 host 的 ssh-agent socket 轉發進容器,交出去的是「幫我簽這段 bytes」的能力,私鑰從頭到尾沒有離開 host。

今天要處理那句話沒講完的部分。先把結局放在這裡:規則套用成功了。錯的規則,成功地套用了。

本日分享的東西都在 public repo 的 dev-container 底下,主角是那支 init-firewall.sh

先講一句大家都應該同意的話

「不要把安全規則寫在 prompt 裡,要寫在模型外面的程式碼。模型會誤解、會忘記、會被 injection 影響,所以要讓它就算判斷錯誤也無法越界。」

這句話是對的,我也是這樣做的。今天要立的這道牆是 iptables,root 才改得動;容器裡的 agent 以非 root 的身分執行;腳本是 root 所有,它沒有寫入權。規則在模型外面,而且在它碰不到的地方。

然後 agent 還是自己把白名單擴大了 🫨!!!

不是它繞過了防火牆規則,是我寫規則的時候少打了兩個引號。更難看的是:它擴大完之後,我的自我驗證腳本照樣在畫面上印出「防火牆已驗證。」

「把規則寫到模型外面」只解決了一半的問題。另一半是:那份寫在外面的規則,誰來驗?而驗它的東西,會不會跟它一起錯?

今天這篇的後半段就是這件事的現場。

那個能力預設沒有目的地範圍

ssh-agent 能做的三件事裡,第二件是「幫我簽這段 bytes」。在一般、沒有加上 destination constraint 的用法裡,sign request 本身不會另外帶一個「我要連哪台主機」的欄位。

所以在沒有其他限制時,容器拿到那個 socket 之後,就能對任何一台連得到、而且認得那把公鑰的主機完成認證。不是只有 GitLab。

如果每台伺服器都用不同的金鑰,這件事還不算太嚴重。但我的金鑰是複用的,Day 19 開頭那段講過。

我當時不知道 OpenSSH 8.9 早在 2022 年 2 月 23 日就加入了一套限制 ssh-agent 金鑰轉送與使用範圍的機制;官方的詳細說明把它稱為 destination constraints。換句話說,不是 SSH 這一層做不到,而是我當時漏掉了既有能力,才把限制往網路層下推。若環境支援,可以在載入金鑰時透過 ssh-add -h 限定可用目的地,替 agent forwarding 再加一道限制。

我當時選的是把限制往網路層下推;今天要做的,就是把這道限制真的立起來。

官方的 dev-container 就有一支

Anthropic 官方的 dev-container 裡本來就有一支 init-firewall.sh

我這份是從它改的。有一整段我原樣沿用,先講那一段,因為它處理的是一個不容易發覺的坑。

Docker 內部 DNS 的 53 埠前面,還有一層 NAT

容器裡看一下 /etc/resolv.conf

nameserver 127.0.0.11

127.0.0.11 是 Docker 自訂網路使用的 embedded DNS server 位址。不過,實際實作不是讓 resolver 直接監聽這個位址的 53 埠,而是由 NAT 規則把封包轉送到 resolver 實際綁定的高位埠。

所以你寫防火牆的第一個動作如果是「先清乾淨」:

iptables -t nat -F

DNS 就一起被清掉了。

我在一顆最小的容器裡實際跑了一次:

flush 前:nat 表裡 127.0.0.11 的規則有 6 條
  解析 OK

只做 iptables -t nat -F,不還原:
  ❌ 解析失敗 — 網域名稱解不出來了

六條規則,清掉就是全死。

麻煩的地方在症狀。這時候你看到的錯誤是「解析不到 xxx.com」,長得像網路壞掉、像 DNS 設定有問題,完全不像「你的防火牆生效了」。你會去查 DNS,不會去查 iptables。

官方腳本的處理是:flush 之前先把那幾條規則撈出來,flush 之後只還原它們。

# 1. flush 之前,先把 Docker 內部 DNS 的 NAT 規則撈出來
DOCKER_DNS_RULES=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)

iptables -t nat -F
# ...其餘 flush

# 3. 只還原 Docker DNS,其餘不還原
if [ -n "$DOCKER_DNS_RULES" ]; then
    iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
    iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
    echo "$DOCKER_DNS_RULES" | xargs -L 1 iptables -t nat
fi

這裡還有一個時間差:

接 Docker 預設 bridge 的時候,/etc/resolv.conf 用的是 host 的 DNS,根本沒有那六條規則。 只有當你把容器接上自己建的 network(Day 18 那個給 gitlab-proxy 用的 named bridge 就是),才會走 127.0.0.11

也就是說,你今天使用這份腳本、覺得這段看不懂而且好像沒作用、順手刪掉,當下不會有任何症狀。它會等到某一天你把容器接上一張自己的網路,才第一次爆給你看。

我改了什麼

白名單、SSH、驗證方式我都調整過。

官方版 我的版本
白名單 GitHub 全 IP 段 + registry.npmjs.org + sentry + statsig + VSCode marketplace + api.anthropic.com 只有 api.anthropic.com
SSH 22 放行到任何主機 只放行 dig 解出來的那台 GitLab
直連網段 從 default route 回推一個 /24 ip route 列出實際的直連網段
驗證 example.com 不通 + api.github.com example.com 不通 + 每一個白名單網域都要通
開關 沒有,一律套用 啟動時由人選

⚠ 表裡的「只有 api.anthropic.com」,指的是用來建立 ipset 的 hostname allowlist。腳本會在啟動時解析它,再把當下取得的 IPv4 位址放進 ipset;真正執法時比對的是 IP,不是 hostname 或 TLS SNI,而且沒有再限制協定或連接埠。同一個 IP 若同時承載其他服務,那些服務也可能跟著變得可達。它不等於「容器什麼都連不出去」:DNS 是放行的,而查詢名稱本身就是一條出境通道。這件事寫在文末,先在這裡標一下,免得中間這幾段被讀成絕對的。

另外,腳本會對所有 directly connected Docker subnet 放行全協定、全連接埠,不只 gitlab-proxy。這是目前為容器間服務接受的 tradeoff,不是 service-level allowlist。

這份腳本目前只設定 IPv4 的 iptables,沒有另外設定 ip6tables。現行容器實測沒有 global IPv6 位址,也沒有 IPv6 default route,因此這個前提目前成立;但它是環境前提,不是永久保證。日後若替 Docker network 啟用 IPv6,就必須同步補上 IPv6 規則,否則這道牆只會罩住 IPv4。啟動時的負向連線測試可能抓到這個缺口,但不能當成保證;啟用 IPv6 時,仍要明確測 IPv6 並補上規則。

兩份腳本要服務的情境不同。官方那份是「讓 dev-container 還能開發」,你在裡面要 clone、要裝套件、要裝 VSCode extension,所以 GitHub、npm、marketplace 都得開。

我的目標是「把容器的直接出網收斂到審查所需」。審查要做的事情很有限:跟模型講話、透過代理讀 GitLab、把報告寫回去。所以我從一張白紙開始加,加到能跑就停。

SSH 22 只留 GitLab 那一台

這條就是文章開頭那件事的解法。

官方那份是不分對象放行 --dport 22,也就是連往任何主機的 SSH 都通。以它的情境(開發用容器,你自己在裡面操作)沒問題。但我的容器裡有一個轉發進來的 agent socket,而且我的金鑰是複用的。這兩件事加起來,22 全開等於「容器被打下來,我所有機器都跟著」。

改成 boot 時解析一次 GitLab 的 IP,只放行到那幾個位址:

gitlab_ips=$(dig +short +tries=2 +time=3 A "$GITLAB_SSH_HOST" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' || true)
while read -r gip; do
    iptables -A OUTPUT -p tcp -d "$gip" --dport 22 -j ACCEPT
    iptables -A INPUT  -p tcp -s "$gip" --sport 22 -m state --state ESTABLISHED -j ACCEPT
done <<< "$gitlab_ips"

$GITLAB_SSH_HOST 在我這裡是公司內部的 GitLab。你如果沒有這個需求,這一整段可以直接拿掉(那就是一條 SSH outbound 都不開);如果你的 git 也在內部某台機器上,就換成你家那台的主機名。

已知限制寫在註解裡:這是開機當下的快照。IP 換了就得重開容器。內部 GitLab 的 IP 很穩定,我接受這個代價;反過來說,如果做成動態跟隨 DNS,等於把白名單的控制權交給 DNS 回應,那不划算。

同一個限制也適用於白名單那邊。api.anthropic.com 走 CDN、TTL 很短,長時間的 session 中途換 IP 的話請求會被擋掉,只能重開容器。

預設拒絕,然後一條一條開

規則排完之後,最後三步:

# 7. 預設 DROP
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT DROP

# 已經建立的連線放行,白名單放行
iptables -A INPUT  -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT

# 8. 其餘 REJECT
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited

三個名詞先解釋一下。

ipset 是一個「IP 位址的集合」,可以整包丟給一條 iptables 規則去比對。不用它的話,每個白名單 IP 都要一條規則。

ESTABLISHED,RELATED 是連線狀態。iptables 記得每一條連線目前走到哪一步:

  • ESTABLISHED — 這個封包屬於一條已經建立起來的連線
  • RELATED — 這個封包屬於一條由既有連線衍生出來的新連線,典型的例子是回傳的 ICMP 錯誤訊息

這兩條為什麼非有不可:INPUT 的預設政策也是 DROP,所以我送出去的請求就算被放行了,對方回來的封包會在 INPUT 被丟掉。結果是每一個連線都建立不起來,包括白名單裡的那些。有了這條,回程封包因為屬於一條既有連線而被放行,我就不必為了收回應而開一堆 INPUT 規則。

REJECT 而不是 DROP,這是刻意的:

  • DROP 是把封包丟掉、什麼都不回。對方會一路等下去。
  • REJECT 是明確回一個「不准」。對方立刻收到錯誤。

我用同一個容器、同一個目標位址各量了一次:

REJECT 等了 0 秒     (更精確一點:curl 回報 2 ms)
DROP   等了 133 秒

這次我沒有另外設定較短的 curl 連線逾時;curl/libcurl 的 connect timeout 預設是 300 秒,但這次 Linux 的 TCP 連線嘗試先在約 133 秒結束。這是當次環境的量測結果,不是跨環境常數。

那兩分鐘裡 agent 不會做任何事,你也不知道它在等什麼。我要的是撞牆的當下就知道撞到牆。

不要相信自己剛剛寫的防火牆

腳本的最後一步是自我驗證:

# example.com 必須連不到
if curl --connect-timeout 5 -sS -o /dev/null https://example.com 2>/dev/null; then
    echo "錯誤:example.com 連得到 — 防火牆沒有生效"
    exit 1
fi
# 每一個白名單網域都必須連得到
for domain in "${ALLOWED_DOMAINS[@]}"; do
    if ! curl --connect-timeout 5 -sS -o /dev/null "https://${domain}/" 2>/dev/null; then
        echo "錯誤:${domain} 連不到 — 白名單沒生效"
        exit 1
    fi
done

兩個方向都要測。 這個做法我是從官方那份學來的。

只測「該擋的有沒有擋住」,會漏掉「我不小心把全部都擋掉了」。那種情況下 agent 從第一次呼叫模型就開始失敗,而它看到的錯誤訊息不會提到 iptables 一個字。你會以為是 API 掛了、以為是憑證過期,查半天才想到牆。

驗證失敗就 exit 1,而 entrypoint 收到失敗會直接結束、不啟動 CLI。這裡的預設值也很重要:牆沒起來就不要放 agent 進來。「規則套用失敗,所以先開著跑吧」是這類腳本最常見的錯誤結尾。

然後我問了一個問題:誰能改這道牆?

這道牆是 root 才能改的,而容器裡的 agent 是以非 root 的 nathan 執行。腳本本身也是 root 所有、nathan 沒有寫入權。這點是前提,不然「只白名單這支腳本」根本沒有意義,能改腳本就等於能改規則。看起來沒問題。

但 entrypoint 要套規則,所以 nathan 得能 sudo 跑那支腳本。我原本是這樣寫的:

nathan ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh

寫這行的時候我的想法是「只白名單這一支腳本,很安全」。

sudoers 的語義是:命令後面沒有列參數,代表任何參數都允許。

而我的腳本會把位置參數當成「要額外放行的網域」加進白名單。兩件事乘起來,容器裡的 agent 只要跑:

sudo /usr/local/bin/init-firewall.sh attacker.example.com

整道牆就會被重建,而且多一個它自己指定的網域。

而且腳本會繼續往下跑到自我驗證,驗證還是會通過:example.com 確實還是不通、白名單網域確實都通得到。所以畫面上印出來的是:

防火牆已驗證。

看起來一切正常。

我把兩邊都改掉。腳本改成完全不吃位置參數,白名單直接寫死在檔案開頭,要加就改檔案再 rebuild;sudoers 這端也把參數鎖成空,"" 表示只准無參數呼叫:

nathan ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh ""

修完再測一次:

$ sudo /usr/local/bin/init-firewall.sh example.com
sudo: a password is required

順便學到一件事:sudoers 寫壞不會讓 build 失敗,會等到你真的 sudo 的那一刻才變成 a password is required。那時候人已經在容器裡了。所以我在 Dockerfile 加了一行 visudo -c 在 build 時驗語法。

政策的來源,不能是被關的人寫得到的地方

同一個問題還有第二個版本。

GitLab 的主機名總得從外面傳進去,最直覺會想到環境變數。但如果這個值最後能由容器裡的 nathan 控制,它就不適合成為安全政策的來源。

所以我沒有採用這條路,而是在 build 時把主機名寫進一個 root 所有、0444 的檔案,腳本再從那裡讀取:

RUN mkdir -p /etc/ncr && \
    printf '%s' "$GITLAB_SSH_HOST" > /etc/ncr/gitlab-ssh-host && \
    chmod 0444 /etc/ncr/gitlab-ssh-host

agent 改不動也蓋不掉(想用 bind mount 蓋過去需要 CAP_SYS_ADMIN,而容器只有 NET_ADMIN)。

說白一點就是:不要讓被關的人自己挑監獄。

參數是呼叫端控制的輸入面,環境變數是執行身分控制的輸入面。兩個都不能當政策來源。

限制或開放,每一場由人選

容器啟動時的第一個問題:

網路能力:
  1 = 限制(白名單) — 一般 TCP 出站收斂到 api.anthropic.com、直連的 docker 網段
                       (gitlab-proxy),SSH 22 只通 build 時指定的那台 GitLab(預設)
                       ⚠ DNS 不在這道牆裡:查詢名稱仍出得去,這不是 DLP
  2 = 完全開放       — 不套用任何 iptables 規則

這三行是啟動選單的用途摘要,不是 ruleset 契約。實際規則仍包含前面提到的 IP-level allowlist,以及 directly connected Docker subnet 的全協定、全連接埠放行;畫面上的「一般 TCP」不能拿來推論完整邊界。DLP 是 Data Loss Prevention(資料外洩防護)的縮寫。

我知道留一個「完全開放」看起來像自己開後門。但我不是只在醫院的情境用這個容器。要做技術研究、要讓瀏覽器自動化真的連得出去的時候,限制模式會擋住它們,而那些場合本來就不該在限制模式下硬幹。

我在意的是三件事,只要這三件成立,這個開關就不是後門:

  1. 選擇是人做的。 這一題在 CLI 啟動之前問,agent 還沒開始跑,沒有任何東西能替它自己選。
  2. 每一場都要重新選。 沒有記憶、沒有「上次選什麼」。
  3. 選了什麼看得見。 畫面上會印出來。

還有一個要區分清楚的東西。Claude Code 的預設值也正在改變。2026 年 7 月 3 日,v2.1.200 才把原本的預設權限模式改名為 Manual;到了 8 月 14 日,Pro、Max 與 Team 方案的新 session 已改由 Auto mode 啟動,除非使用者或管理員另外固定了預設值。Auto mode 不再要求人逐項按下核准,而是把每次工具呼叫交給背景 classifier 判斷。有人可能會覺得:那不就取代這道牆了嗎?

不是同一層的東西。

權限模式決定的是「這個動作由誰判斷能不能執行」,防火牆決定的是「封包實際能不能出去」。 一個在 Auto mode 下被 classifier 放行的 curl,跟那個 curl 打不打得出去,是兩件事。前者是執行授權,後者是網路可達性。就算之後 Anthropic 把分類器做得再聰明,這道牆的理由都不會過期。

這道牆封不住的兩條路

規則寫完、驗證通過、開關也做好了。然後我遇到一件事。

有一次 Claude 講出了一個我們只寫在 Google Doc 上的資訊。

當下第一個念頭是「我漏了什麼設定」,第二個念頭是「原來這條路一直在」。兩個念頭幾乎是同時的。

我沒有去翻文件,我直接問它:你怎麼知道這件事的?

它回答說,這個資訊來自我在 claude.ai 帳號上綁定的 Connector。這個解釋也符合官方架構:remote Connector 從 Anthropic 的雲端連向資料來源,不是在我的容器裡執行。所以整個過程裡,我的容器從頭到尾只跟 api.anthropic.com 講過話。

下面三張是同一場操作的連續畫面:容器裡直接連 Google Docs,以及由 Claude 後端代抓網頁都沒有拿到文件內容;Google Drive Connector 則透過帳號已授權的通道讀回私人文件。這不是防火牆失效,而是三條路原本就不在同一個執行邊界。

直接連線失敗,Google Drive Connector 開始讀取

容器沒有直接對外網路,但 Connector 仍能讀回私人文件

三條取用路徑中,只有 Google Drive Connector 成功

這條路還有一個容易漏掉的認證前提:不是只要 Claude Code 能呼叫模型,就一定會把帳號上的 Connector 帶進來。使用 Claude.ai subscription 的互動式 /login 時,帳號 Connector 才會自動出現在 Claude Code;若改用 claude setup-token 產生的 CLAUDE_CODE_OAUTH_TOKEN,目前實作不會同步這些帳號 Connector(相關 issue)。這不代表 MCP 整體不能用;另外在 Claude Code 設定的 MCP server,仍可用自己的認證獨立連線。

我後來才把這件事想清楚:

我能封鎖的是我的機器連出去。我封鎖不了我允許連的那個對象,替我連出去

而這個缺口沒有辦法靠這道本機防火牆補起來,不是多寫一條 iptables 規則就能解決。api.anthropic.com 一定要開,不開就沒有 agent。只要那條路開著,伺服器端能做的事情就都在牆外面。控制點不在 iptables,而在帳號的 Connector 設定與 Claude Code 採用的認證路徑。

同一類的東西還有 WebSearch。官方文件講得很直接,它是「跑在 Anthropic 的搜尋後端」,那當然不經過我的網路。

後來某天的一場審查,這道牆的形狀全被拍進一張畫面裡:兩條直連出不去(其中一條當場收到 refused:錯誤是立刻回來的,不是等逾時等出來的),WebSearch 卻好好的,因為它從頭到尾沒走過我的網路。至於第一次搜尋撞到的 400:那是 stackoverflow 在擋 Anthropic 的爬蟲。別人家的牆,同一個姿態,而且它把「為什麼擋你」講了出來。

另一個方向:牆內,但照樣出得去

Connector 是「牆外面的事」。還有一種相反的:在牆裡面,走的是我自己批准的路,資料照樣出得去。

前面講 Docker 內部 DNS 的時候,我只講了它會被 flush 清掉。它還有另一面。

我把一顆容器接上自建網路,然後把防火牆收到最緊:OUTPUT 預設 DROP,除了 loopback 什麼都不放行,連 --dport 53 那條都不留。HTTP 直接逾時,牆是硬的。

然後我查一個當場亂湊出來的子網域:

--- 只放行 lo,其餘全 DROP
wget https://example.com  →  download timed out      ✓ 牆有效

查詢:f25c18a7e510a6a25a466d06b0e8676abc0f5dc91d49359d.example.com
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN

NXDOMAIN 是一個答案。至少可以確定,那 48 個字元已經離開容器,交給 Docker 的 DNS 處理。

但這次實驗沒有控制網域另一端的 DNS server,也沒有查詢紀錄,所以不能只憑這個答案斷言那串字一定抵達了 example.com:中途的 DNS resolver 也可能直接回答「不存在」。

牆是硬的,可是那串字離開容器了。

原因跟 Connector 是同一句話。容器的 /etc/resolv.conf 寫的是 127.0.0.11。查詢仍然會經過 OUTPUT,但正常規則會先放行 loopback 流量;而且 NAT 已把目的連接埠 53 改寫成 resolver 實際使用的高位連接埠,所以後面的 --dport 53 規則比對不到。接著,Docker daemon 會在容器的網路命名空間外替我繼續處理查詢。

上面那句話我是為 Connector 寫的:我封鎖不了我允許連的那個對象,替我連出去。把「那個對象」換成 Docker daemon,一個字都不用改。

這裡順手做了一組對照,結果比我預期的乾淨:

第一條規則 = REJECT  udp --dport 53      →  解析成功,規則計數器 0 packets
第一條規則 = REJECT  -d 127.0.0.11       →  解析 refused,計數器 1 packet 80 bytes

也就是說,腳本裡那條 iptables -A OUTPUT -p udp --dport 53 -j ACCEPT 在自建網路上根本沒有在承擔 DNS,因為 nat 表的 DNAT 跑在 filter 之前,封包到 OUTPUT 鏈的時候目的埠早就被改掉了,比對 53 的規則看不到它。那條規則是留給「接 Docker 預設 bridge、resolv.conf 指向外部 resolver」的那種情況。

這件事的另一面是:想靠「封掉 53」來收這條路的人,會封到一條一個封包都不會經過的規則,然後以為封好了。

這條路真的有人在走

把資料編進 DNS 查詢名稱送出去,通常稱為 DNS exfiltration;若進一步把 DNS 當成持續、甚至雙向的傳輸通道,則常稱為 DNS tunneling。近幾年有廠商報告背書的幾筆:

  • Decoy Dog(Infoblox,2023 年 4 月揭露):以 DNS 建立 command and control(C2,命令與控制)的 remote access trojan(RAT,遠端存取木馬)工具組,2022 年 4 月就在活動,整整一年沒被發現,受害者橫跨科技、醫療、能源、金融。
  • TrkCdn / SpamTracker / SecShow(Palo Alto Unit 42,2024 年 5 月):三組把資料編進子網域的行動。一組把電子郵件地址算成 MD5 塞進去,一組把 IP 用十六進位塞進去。
  • Zloader 2.9.4.0(Zscaler ThreatLabz,2024 年 12 月):自製的 DNS 隧道協定當 C2,用 A 與 AAAA 記錄封裝加密過的流量。

還有一件事值得單獨講:對方可以一個封包都不回。 資料在查詢送出去的那一刻就已經到他手上了,回應只有在需要雙向通道的時候才用得到。所以「那個網域根本查不到」不代表沒有東西出去。

那為什麼不補

幾條路我都想過一遍,寫在 docs/dns-egress-notes.md

做法 卡在哪
直接封 53 白名單是網域,要解析才成立,封了連 api.anthropic.com 都解不出來。而且照上面那個計數器,在自建網路上「封 53」封不到東西,要擋得改成擋 127.0.0.11
開機時把白名單解析完,再封 53 CNAME 得遞迴到底;CDN 的 IP 會隨 TTL 漂移,長 session 中途斷線,而且症狀像逾時不像被擋
自己架 resolver,只回答核准的網域 多一個元件要顧,而且核准網域底下的子網域一樣能編資料,比對得精確而不能只看後綴
靠偵測(查詢長度、熵、頻率) 那是統計判斷,不是邊界。低頻慢速的外送跟正常查詢分不開

第二條跟前面 SSH 那段是同一個矛盾:動態跟隨 DNS,等於把白名單的控制權交給 DNS 回應。 我在 SSH 那裡選了「開機解析一次、IP 換掉就重開容器」,那個代價在 IP 很穩定的內部 GitLab 上付得起;換到走 CDN 的 api.anthropic.com,就付不起了。

所以這一條我目前是知道、寫下來、沒有封。它在 repo 裡是一條講明白的已知限制,不是一個我沒想到的洞。

這件事我不打算只用嘴巴講。後面有一天,我會把容器裡跑出去的流量攤開來給大家看,順便看看有哪些是攤不開的。

本日小結

今天這道牆的形狀,其實跟前面兩天是同一個:預設不給,需要什麼再單獨開。

Day 18 的代理只放行六條規則,其餘一律 403。今天的防火牆預設 DROP,再分別放行模型 API 啟動時解析出的 IPv4、必要的直連 Docker 網段,以及有 ssh-agent 時的指定 GitLab SSH。同一個姿態,換了一層。

實作上我學到四件事:

  1. Docker embedded DNS 的 53 埠依賴 NAT 轉送規則。 flush 之前要先撈出來。而且這個坑會等到你把容器接上自己的網路那天才第一次出現。
  2. REJECT 而不是 DROP。 兩毫秒的錯誤,好過兩分多鐘的沉默。
  3. 自我驗證要測兩個方向。 只測「該擋的有沒有擋」,會漏掉「我把全部都擋掉了」。
  4. 每一道防線都要再問一次「誰能改它」。 我的 sudoers 少寫了兩個引號,agent 就能自己擴大白名單,而且畫面照樣印出「已驗證」。

Day 1 那張表裡的「令之以文,齊之以武」,到這裡兩半都有了:文是 Day 3Day 13 那份寫給它讀的 skill,武是 Day 17 之後這幾道由外部機制執行、不再只靠 agent 自律的邊界。

然後是那兩個補不起來的缺口。我到今天還是覺得 Connector 那件事的價值不在於它有多危險,而在於它讓我看清楚這道牆的形狀:牆畫在我的機器周圍,而不是畫在我的資料周圍。這兩者在多數時候重疊,但不是同一件事。DNS 那條是同一句話的另一半:封包確實是從我的機器出去的,也確實是我自己放行的,但它帶出去的是我的資料。

明天換一個問題。牆立起來之後,我開始想知道容器裡面到底發生了什麼:一場審查花了多少時間、多少錢,而那些錢是花在哪個環節上。


上一篇
Day 19|讓 AI Agent 借得到簽名,拿不到印鑑:ssh-agent
下一篇
Day 21|用 OpenTelemetry 幫 AI 審查跑一次 Profiler:量出最冤枉的 13 分鐘
系列文
AI 的駕馭之道:一個 AI Code Reviewer 的養成、評測與邊界實錄30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言